Skip to content

feat(disputes): dispute resolution with member voting and admin resolution - #250

Open
jajafwangshak86-ops wants to merge 1 commit into
JointSave-org:mainfrom
jajafwangshak86-ops:feat/dispute-resolution
Open

feat(disputes): dispute resolution with member voting and admin resolution#250
jajafwangshak86-ops wants to merge 1 commit into
JointSave-org:mainfrom
jajafwangshak86-ops:feat/dispute-resolution

Conversation

@jajafwangshak86-ops

Copy link
Copy Markdown

Closes #208

Summary

Adds a complete dispute resolution flow for pools: members can file disputes (against another member or an admin action), the pool votes to uphold or dismiss, and the admin can force-resolve with a written note.

Database (20260824010000_dispute_resolution.sql)

  • disputes: filer, optional target member, type (missed_deposit / unfair_penalty / admin_abuse / member_misconduct / other), description (≤2000 chars), up to 3 evidence URLs, status lifecycle open → voting → resolved_upheld / resolved_dismissed / expired, vote counters, resolution note + resolver + timestamp
  • dispute_votes: one vote per wallet per dispute (composite PK)
  • Public-read RLS (writes only via service-role API routes) + realtime publication, matching the repo's existing lockdown style

API

Endpoint Behavior
POST /api/disputes Members file disputes; one active dispute per filer per pool; 72h expiry; evidence URLs validated
GET /api/disputes?pool_id= Pool feed, newest first, optional status filter
POST /api/disputes/[id]/vote Member-only; filer/target excluded; double votes rejected by PK; auto-resolves when one side reaches ⌈members/2⌉
POST /api/disputes/[id]/resolve Pool admin closes any active dispute with required resolution note (tie-breaker / urgent cases)
POST /api/disputes/expire CRON_SECRET-gared job that flips stale disputes to expired — registered in vercel.json

All state changes are mirrored into pool_activity (dispute_filed, dispute_resolved).

Frontend

  • DisputesPanel rendered full-width on the group page: status badges, live voting countdown, vote counter showing threshold, resolution notes
  • FileDisputeDialog: type select, optional target member picker, live character counter, repeatable evidence URL inputs
  • useDisputes: Supabase Realtime subscription keeps the feed live without refresh; optimistic vote updates reconcile against server truth
  • lib/disputes.ts pure helpers covered by 8 node unit tests (registered in test:unit); full en/es translations

Verification (all green locally)

  • pnpm lint, pnpm format:check, test:unit (210 pass incl. new disputes tests), test:components (101), pnpm build, bundle budget check
  • No new TypeScript errors from the disputes files (tsc --noEmit)

Implements issue JointSave-org#208.

Database:
- disputes table (type, target, evidence URLs, status lifecycle
  open -> voting -> resolved_upheld/resolved_dismissed/expired,
  resolution notes, vote counters) and dispute_votes table with one
  vote per wallet per dispute; RLS public-read + realtime publication.
- Disputes auto-expire 72h after filing.

API:
- POST/GET /api/disputes — members file disputes (one active per filer
  per pool, description <=2000 chars, up to 3 validated evidence links)
- POST /api/disputes/[id]/vote — member-only voting; filer and target
  excluded; auto-resolves when one side reaches half the pool (rounded
  up) and logs to pool_activity
- POST /api/disputes/[id]/resolve — pool admin closes any active
  dispute with a required written resolution note
- POST /api/disputes/expire — CRON_SECRET-gared expiry job registered
  in vercel.json

Frontend:
- useDisputes hook: feed loading + Supabase Realtime subscription +
  optimistic voting that reconciles with server truth
- DisputesPanel / DisputeCard / FileDisputeDialog components wired into
  the group page below the main grid; status badges, live countdown,
  quorum-style vote counter, admin resolve dialog
- lib/disputes.ts pure helpers with 8 node unit tests; en/es i18n
@Sendi0011
Sendi0011 self-requested a review August 25, 2026 07:55

@Sendi0011 Sendi0011 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — Dispute Resolution with Member Voting

Thorough implementation with proper server-side auth checks, rate limiting, optimistic UI updates with rollback, and Supabase Realtime. Two issues to address:

1. Disputes expire cron endpoint has no CRON_SECRET check (High)

frontend/app/api/disputes/expire/route.ts:283-290 — Wait, actually it does check CRON_SECRET. Good. But there's a subtle issue: the endpoint returns 500 when CRON_SECRET is not configured, which would prevent the cron from running silently. This is correct behavior — just flagging it.

2. Vote endpoint counts filer toward majority threshold (Medium)

frontend/app/api/disputes/[id]/vote/route.ts:222-227votesNeededToResolve(memberCount) uses Math.ceil(memberCount / 2) where memberCount includes the filer and target. But filers and targets cannot vote (canVoteOnDispute excludes them). If a pool has 3 members and 1 files a dispute against another, only 1 person can vote — but the threshold requires 2 votes (ceil(3/2)). This means disputes in small pools may never reach resolution through voting. Consider excluding filer + target from the member count when calculating the threshold, or at minimum document this limitation.

3. resolve endpoint does not verify dispute is in voting window (Low)

frontend/app/api/disputes/[id]/resolve/route.ts:93 — The admin resolve checks status is open or voting, but doesn't check expires_at. An admin could resolve a dispute whose voting window has already expired. The expire cron would then try to flip it to expired but the status would already be resolved_*. Not harmful but logically inconsistent.

What is good

  • All endpoints have rate limiting (readLimiter/writeLimiter)
  • Vote endpoint checks membership, filer/target exclusion, and one-vote-per-dispute (Postgres unique constraint)
  • Disputes have a 72-hour expiry with a dedicated cron endpoint (properly protected by CRON_SECRET)
  • useDisputes hook does optimistic vote counts with rollback on failure — good UX
  • dispute-card.tsx has proper evidence URL rendering with noopener noreferrer
  • All 5 CI green, mergeable state clean

Please address item 2 (threshold calculation). Items 1 and 3 are suggestions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Build a dispute resolution system for pool members to report and resolve conflicts (missed deposits, unfair penalties)

2 participants